/*
 * Copyright (c) 2015 the original author or authors.
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License v1.0
 * which accompanies this distribution, and is available at
 * http://www.eclipse.org/legal/epl-v10.html
 *
 * Contributors:
 *     Simon Scholz (vogella GmbH) - initial API and implementation and initial documentation
 */

package org.eclipse.buildship.ui.view.execution;

import java.io.File;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.regex.Pattern;

import org.gradle.tooling.events.OperationDescriptor;
import org.gradle.tooling.events.task.TaskOperationDescriptor;
import org.gradle.tooling.events.test.JvmTestOperationDescriptor;

import com.google.common.base.Optional;
import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableList;
import com.google.common.io.Files;

import com.gradleware.tooling.toolingmodel.OmniEclipseProject;
import com.gradleware.tooling.toolingmodel.Path;
import com.gradleware.tooling.toolingmodel.repository.FetchStrategy;

import org.eclipse.core.resources.IFile;
import org.eclipse.core.resources.IProject;
import org.eclipse.core.resources.IResource;
import org.eclipse.core.resources.IResourceVisitor;
import org.eclipse.core.resources.ResourcesPlugin;
import org.eclipse.core.runtime.CoreException;
import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.core.runtime.IStatus;
import org.eclipse.core.runtime.OperationCanceledException;
import org.eclipse.core.runtime.SubMonitor;
import org.eclipse.jdt.core.IJavaElement;
import org.eclipse.jdt.core.IJavaProject;
import org.eclipse.jdt.core.IMethod;
import org.eclipse.jdt.core.IType;
import org.eclipse.jdt.core.JavaCore;
import org.eclipse.jdt.core.JavaModelException;
import org.eclipse.jdt.core.search.IJavaSearchConstants;
import org.eclipse.jdt.core.search.IJavaSearchScope;
import org.eclipse.jdt.core.search.SearchEngine;
import org.eclipse.jdt.core.search.SearchMatch;
import org.eclipse.jdt.core.search.SearchParticipant;
import org.eclipse.jdt.core.search.SearchPattern;
import org.eclipse.jdt.core.search.SearchRequestor;
import org.eclipse.jdt.ui.JavaUI;
import org.eclipse.jface.text.BadLocationException;
import org.eclipse.jface.text.FindReplaceDocumentAdapter;
import org.eclipse.jface.text.IDocument;
import org.eclipse.jface.text.IRegion;
import org.eclipse.swt.widgets.Display;
import org.eclipse.ui.IEditorPart;
import org.eclipse.ui.PlatformUI;
import org.eclipse.ui.editors.text.TextFileDocumentProvider;

import org.eclipse.buildship.core.CorePlugin;
import org.eclipse.buildship.core.configuration.BuildConfiguration;
import org.eclipse.buildship.core.configuration.RunConfiguration;
import org.eclipse.buildship.core.util.progress.ToolingApiWorkspaceJob;
import org.eclipse.buildship.core.workspace.ModelProvider;
import org.eclipse.buildship.ui.UiPlugin;
import org.eclipse.buildship.ui.util.editor.EditorUtils;

/**
 * Opens the test source files for the given
 * {@link org.eclipse.buildship.ui.view.execution.OperationItem} test nodes. Knows how to handle
 * both Java and Groovy test source files.
 */
public final class OpenTestSourceFileJob extends ToolingApiWorkspaceJob {

    private final ImmutableList<OperationItem> operationItems;
    private final RunConfiguration runConfig;

    public OpenTestSourceFileJob(List<OperationItem> operationItems, RunConfiguration runConfig) {
        super("Opening test source files");
        this.operationItems = ImmutableList.copyOf(operationItems);
        this.runConfig = Preconditions.checkNotNull(runConfig);
    }

    @Override
    protected void runToolingApiJobInWorkspace(IProgressMonitor monitor) throws Exception {
        SubMonitor subMonitor = SubMonitor.convert(monitor, this.operationItems.size());
        for (OperationItem operationItem : this.operationItems) {
            if (monitor.isCanceled()) {
                throw new OperationCanceledException();
            } else {
                searchForTestSource(operationItem, subMonitor.newChild(1));
            }
        }
    }

    private void searchForTestSource(OperationItem operationItem, SubMonitor monitor) throws CoreException {
        OperationDescriptor operationDescriptor = (OperationDescriptor) operationItem.getAdapter(OperationDescriptor.class);
        if (operationDescriptor instanceof JvmTestOperationDescriptor) {
            JvmTestOperationDescriptor testOperationDescriptor = (JvmTestOperationDescriptor) operationDescriptor;
            String className = testOperationDescriptor.getClassName();
            Optional<Path> projectPath = findProjectPath(operationDescriptor);
            if (className != null && projectPath.isPresent()) {
                String methodName = testOperationDescriptor.getMethodName();
                searchForTestSource(className, methodName, projectPath.get(), monitor);
            }
        }
    }

    private Optional<Path> findProjectPath(OperationDescriptor operationDescriptor) {
        OperationDescriptor parent = operationDescriptor.getParent();
        if (parent != null) {
            if (parent instanceof TaskOperationDescriptor) {
                Path taskPath = Path.from(((TaskOperationDescriptor) parent).getTaskPath());
                return Optional.of(taskPath.dropLastSegment());
            } else {
                return findProjectPath(parent);
            }
        }
        return Optional.absent();
    }

    private void searchForTestSource(String className, String methodName, Path projectPath, SubMonitor monitor) throws CoreException {
        monitor.setTaskName(String.format("Open test source file for class %s.", className));
        monitor.setWorkRemaining(2);
        List<IProject> project = findProjectContainingTest(projectPath, monitor);
        boolean found = searchForJavaTest(className, methodName, project, monitor.newChild(1));
        if (!found) {
            searchForGroovyTest(className, methodName, project, monitor.newChild(1));
        }
    }

    private boolean searchForJavaTest(String className, String methodName, List<IProject> projects, IProgressMonitor monitor) throws CoreException {
        SearchEngine searchEngine = new SearchEngine();
        SearchPattern pattern = SearchPattern.createPattern(className, IJavaSearchConstants.TYPE, IJavaSearchConstants.DECLARATIONS, SearchPattern.R_EXACT_MATCH);
        ShowTestSourceFileSearchRequester requester = new ShowTestSourceFileSearchRequester(methodName);
        searchEngine.search(pattern, new SearchParticipant[]{ SearchEngine.getDefaultSearchParticipant() }, createSearchScope(projects, monitor), requester, monitor);
        return requester.isFoundJavaTestSourceFile();
    }

    private void searchForGroovyTest(String className, String methodName, List<IProject> projects, SubMonitor monitor) throws CoreException {
        ShowTestSourceFileResourceVisitor visitor = new ShowTestSourceFileResourceVisitor(methodName, className, ImmutableList.of("groovy")); //$NON-NLS-1$
        if (projects.isEmpty()) {
            ResourcesPlugin.getWorkspace().getRoot().accept(visitor);
        } else {
            for (IProject project : projects) {
                project.accept(visitor);
            }
        }
    }

    private List<IProject> findProjectContainingTest(Path projectPath, IProgressMonitor monitor) {
        File workingDir = this.runConfig.getProjectConfiguration().getProjectDir();
        Optional<IProject> project = CorePlugin.workspaceOperations().findProjectByLocation(workingDir);
        if (!project.isPresent()) {
            return Collections.emptyList();
        }

        BuildConfiguration buildConfig = CorePlugin.configurationManager().loadProjectConfiguration(project.get()).getBuildConfiguration();
        ModelProvider modelProvider = CorePlugin.gradleWorkspaceManager().getGradleBuild(buildConfig).getModelProvider();
        Set<OmniEclipseProject> eclipseProjects = modelProvider.fetchEclipseGradleProjects(FetchStrategy.LOAD_IF_NOT_CACHED, getToken(), monitor);

        List<IProject> result = new ArrayList<>();
        for (OmniEclipseProject eclipseProject : eclipseProjects) {
            if (eclipseProject.getPath().equals(projectPath)) {
                Optional<IProject> workspaceProject = CorePlugin.workspaceOperations().findProjectByName(eclipseProject.getName());
                if (workspaceProject.isPresent() && workspaceProject.get().isAccessible()) {
                    result.add(workspaceProject.get());
                }
            }
        }

        return result;
    }

    private IJavaSearchScope createSearchScope(List<IProject> projects, IProgressMonitor monitor) throws CoreException {
        List<IJavaProject> javaProjects = new ArrayList<>();
        for (IProject project : projects) {
            if (project.isAccessible() && project.hasNature(JavaCore.NATURE_ID)) {
                javaProjects.add(JavaCore.create(project));
            }
        }

        if (javaProjects.isEmpty()) {
            return SearchEngine.createWorkspaceScope();
        } else {
            return SearchEngine.createJavaSearchScope(javaProjects.toArray(new IJavaProject[0]));
        }
    }

    /**
     * Match the type and potentially also the method name.
     */
    private final class ShowTestSourceFileSearchRequester extends SearchRequestor {

        private final String methodName;
        private final AtomicBoolean foundJavaTestSourceFile;

        private ShowTestSourceFileSearchRequester(String methodName) {
            this.methodName = methodName;
            this.foundJavaTestSourceFile = new AtomicBoolean(false);
        }

        @Override
        public void acceptSearchMatch(SearchMatch match) throws CoreException {
            if (match.getElement() instanceof IType) {
                this.foundJavaTestSourceFile.set(true);

                IType classElement = (IType) match.getElement();
                IJavaElement methodElement = findMethod(this.methodName, classElement);
                openInEditor(methodElement != null ? methodElement : classElement);
            }
        }

        private IJavaElement findMethod(String methodName, IType type) {
            // abort search for invalid method names
            @SuppressWarnings("restriction")
            IStatus status = org.eclipse.jdt.internal.corext.util.JavaConventionsUtil.validateMethodName(methodName, type);
            if (!status.isOK()) {
                return null;
            }

            // find parameter-less method by name
            IMethod method = type.getMethod(methodName, new String[0]);
            if (method != null && method.exists()) {
                return method;
            }

            // search textually by name (for custom runner with test methods having parameters)
            try {
                for (IMethod methodItem : type.getMethods()) {
                    if (methodItem.getElementName().equals(methodName)) {
                        return methodItem;
                    }
                }
                return null;
            } catch (JavaModelException e) {
                // ignore and treat as no method being found
                return null;
            }
        }

        private void openInEditor(final IJavaElement javaElement) {
            PlatformUI.getWorkbench().getDisplay().syncExec(new Runnable() {

                @Override
                public void run() {
                    try {
                        JavaUI.openInEditor(javaElement);
                    } catch (Exception e) {
                        String message = String.format("Cannot open Java element %s in editor.", javaElement);
                        UiPlugin.logger().error(message, e);
                    }
                }
            });
        }

        private boolean isFoundJavaTestSourceFile() {
            return this.foundJavaTestSourceFile.get();
        }

    }

    /**
     * Find the file for the given class name as a resource in the workspace.
     */
    private static final class ShowTestSourceFileResourceVisitor implements IResourceVisitor {

        private static final String BIN_FOLDER_NAME = "bin"; //$NON-NLS-1$

        private final String methodName;
        private final String className;
        private final ImmutableList<String> fileExtensions;

        private ShowTestSourceFileResourceVisitor(String methodName, String className, List<String> fileExtensions) {
            this.methodName = methodName;
            this.className = Preconditions.checkNotNull(className);
            this.fileExtensions = ImmutableList.copyOf(fileExtensions);
        }

        @Override
        public boolean visit(final IResource resource) throws CoreException {
            // short-circuit if the resource is not a file with one of the requested extensions
            if (resource.getType() != IResource.FILE || !this.fileExtensions.contains(resource.getFileExtension())) {
                return true;
            }

            // prepare to compare package path of the requested class name with the project path of
            // the given resource
            String classNameToPath = this.className.replaceAll(Pattern.quote("."), "/"); //$NON-NLS-1$ //$NON-NLS-2$
            String projectRelativePath = resource.getProjectRelativePath().toString();

            // short-circuit if the resource is in the bin folder or if the paths do not match
            if (projectRelativePath.startsWith(BIN_FOLDER_NAME) || !projectRelativePath.contains(classNameToPath)) {
                return true;
            }

            // short-circuit if the resource does not map to a file
            @SuppressWarnings({ "cast", "RedundantCast" })
            final IFile file = (IFile) resource.getAdapter(IFile.class);
            if (file == null) {
                return true;
            }

            // open the requested class and optionally mark the requested method
            Display display = PlatformUI.getWorkbench().getDisplay();
            display.syncExec(new Runnable() {

                @Override
                public void run() {
                    IEditorPart editor = EditorUtils.openInInternalEditor(file, true);
                    IRegion region = getClassOrMethodRegion(file);
                    if (region != null) {
                        EditorUtils.selectAndReveal(region.getOffset(), region.getLength(), editor, file);
                    }
                }
            });
            return false;
        }

        private org.eclipse.jface.text.IRegion getClassOrMethodRegion(IFile file) {
            // if no method name is available find the class name
            if (this.methodName == null) {
                try {
                    FindReplaceDocumentAdapter documentAdapter = createFindReplaceDocumentAdapter(file);
                    return find(documentAdapter, Files.getNameWithoutExtension(file.getName()));
                } catch (Exception e) {
                    // ignore and treat as no method being found
                    return null;
                }
            }

            // try to find method name and fall back to class name if method name cannot be found
            try {
                FindReplaceDocumentAdapter documentAdapter = createFindReplaceDocumentAdapter(file);
                IRegion region = find(documentAdapter, this.methodName);
                if (region == null) {
                    documentAdapter = createFindReplaceDocumentAdapter(file);
                    return find(documentAdapter, Files.getNameWithoutExtension(file.getName()));
                }
                return region;
            } catch (Exception e) {
                // ignore and treat as no method being found
                return null;
            }
        }

        private FindReplaceDocumentAdapter createFindReplaceDocumentAdapter(IFile file) throws CoreException {
            TextFileDocumentProvider textFileDocumentProvider = new TextFileDocumentProvider();
            textFileDocumentProvider.connect(file);
            IDocument document = textFileDocumentProvider.getDocument(file);
            return new FindReplaceDocumentAdapter(document);
        }

        private IRegion find(FindReplaceDocumentAdapter findReplaceDocumentAdapter, String findString) throws BadLocationException {
            return findReplaceDocumentAdapter.find(0, findString, true, true, false, false);
        }

    }

}
